#--
# Active Directory Module for Ruby
#
# Copyright (c) 2005-2006 Justin Mecham
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#++

module ActiveDirectory

  #
  # The ActiveDirectory module contains the classes used for communicating with
  # Active Directory LDAP servers. ActiveDirectory::Base is the basis from which
  # all classes derive.
  #
  class Base
    @@connection = nil

    #
    # Configuration
    #
    @@server_settings = {
      :host     => "localhost",
      :port     => 389,
      :username => nil,
      :password => nil,
      :domain   => nil,
      :base_dn  => nil,
      :bind_dn  => nil
    }
    cattr_accessor :server_settings

    #
    # Logging
    #
    cattr_writer :logger
    @@logger = nil

    def self.log(facility, message)
      @@logger.send(facility.to_sym,"ActiveDirectory: #{message}") if @@logger
    end

    @@preferred_method = nil

    #
    # Basic Stuff Common to ALL AD Entries
    #
    attr_reader :dn
    attr_reader :objectClass

    #
    # Returns a connection to the configured Active Directory instance. If a
    # connection is not already established, it will attempt to establish the
    # connection.
    #
    def self.connection
      unless @@connection
        @@connection = Connection.new(@@server_settings[:host],@@server_settings[:port])
        username = "#{@@server_settings[:username]}@#{@@server_settings[:domain]}"
        username = @@server_settings[:bind_dn] if @@server_settings[:bind_dn]
        @@connection.connect(username,@@server_settings[:password])
      end
      @@connection
    end

    #
    # Search interface for querying for objects within the directory, such as
    # users and groups. Searching the directory is quite similar to a normal
    # LDAP search, but with a better API.
    #
    # If you call this method from User or Group, it will narrow your searches
    # to those specific objects by default. Calling this method on Base will
    # return objects of any class and provides the most flexibility.
    #
    # ==== Searching Users
    #
    # Users may be located within the directory by their distinguished name
    # (DN), their username (sAMAccountName), or through any other valid LDAP
    # filter and optional search base.
    #
    #  # Load all users (including disabled) within the default Base DN.
    #  all_users      = ActiveDirectory::User.find(:all)
    #
    #  # Load all disabled users within the default Base DN.
    #  disabled_users = ActiveDirectory::User.find(:all,
    #                     :filter => "(userAccountControl=514)")
    #
    #  # Load all users who are in the Managers organizational unit whose
    #  # accounts are not disabled.
    #  managers       = ActiveDirectory::User.find(:all,
    #                     :base   => "OU=Managers,DC=example,DC=com",
    #                     :filter => "(userAccountControl=512)")
    #
    #  # Load the user "John Doe" by his sAMAccountName.
    #  user = ActiveDirectory::User.find("jdoe")
    #
    #  # Load the user "John Doe" by his distinguished name (DN).
    #  user = ActiveDirectory::User.find("CN=John Doe,CN=Users,DC=example,DC=com")
    #
    # ==== Searching Groups
    #
    # Groups may be located within the diretctory by their distinguished name
    # (DN) or through any other valid LDAP filter and optional search base.
    #
    #  # Load all groups within the default Base DN.
    #  all_groups = ActiveDirectory::Group.find(:all)
    #
    #  # Load the "Developers" group by its distinguished name (DN).
    #  developers = ActiveDirectory::Group.find("CN=Developers,DC=example,DC=com")
    #
    # ==== More Advanced Examples
    #
    # By calling ActiveDirectory::Base#find you can query objects across
    # classes, allowing you to pull in both Groups and Users that match your
    # criteria.
    #
    #  # Load all Contacts
    #  contacts = ActiveDirectory::Base.find(:all,
    #               :filter => "(&(objectClass=User)(objectCategory=Contact))")
    #
    def self.find(*args)
      attributes = [ "distinguishedName", "objectClass" ]
      options = {
        :base => @@server_settings[:base_dn],
        :scope => LDAP::LDAP_SCOPE_SUBTREE
      }.merge(extract_options_from_args!(args))
      
      # Determine the appropriate search filter
      filter = self.filter(options[:filter])

      # Load all matching objects
      if args.first == :all
        log(:debug, "Searching Active Directory - Filter: #{filter} - Search Base: #{options[:base]} - Scope: #{options[:scope]}")

        # Perform search
      	entries = self.search(options[:base], options[:scope], filter, attributes)

        result = []
        unless entries.nil?
          for entry in entries
            result << new.quick_create(entry)
          end
        end
        result
      else
        # Load a single matching object by either a common name or a
        # sAMAccountName
        if args.first =~ /(CN|cn)=/
          options[:base] = args.first
        else
          filter = self.filter("(#{primary_identifier_attribute}=#{escape_non_ascii(args.first)})#{options[:filter]}") # QUESTION: do we need the options[:filter]?
        end

        log(:debug, "Searching Active Directory - Filter: #{filter} - Search Base: #{options[:base]} - Scope: #{options[:scope]}")

        begin
          entry = self.search(options[:base], options[:scope], filter, attributes)
        rescue
          if $!.message == "No such object"
            raise NotFoundError
          elsif $!.message == "no result returned by search"
            raise NotFoundError
          else
            raise
          end
        end

       	raise NotFoundError unless entry
        new.quick_create(entry[0])
      end

    end

    #
    # Attempt to load a User by its Distinguished Name (DN).
    #
    def load_by_dn(dn)
      raise ArgumentError, "No distinguished name provided." if dn.nil? or dn.empty?
      entries = Base.search(dn.gsub(/"/,"\\\""), LDAP::LDAP_SCOPE_BASE, self.class.filter)
      raise NotFoundError if (entries.nil? or entries.length != 1)
      parse_ldap_attributes(entries[0])
      @schema = LDAP::Schema.new(entries[0])
      self
    end
    attr_reader :schema

    def quick_create(entry)
      @dn = entry['distinguishedName'][0]
      @objectClass = entry['objectClass']
      @remote_attributes = {}
      @local_attributes = {}
      @new_record = false
      self
    end

    def new_record?
      @new_record
    end

    def attributes(attr_name)
      attr_name = attr_name.to_sym
      load_by_dn(@dn) if @remote_attributes.empty?
      if @local_attributes.has_key? attr_name
        @local_attributes[attr_name]
      else
        @remote_attributes[attr_name]
      end
    end

    #####################################
    #Updates
    #####################################
    def update_attribute(attr_name, new_value)
      update_attributes(attr_name => new_value)
    end

    def update_attributes(attrs)
      Base.connection.ensure_connectivity do |live|
        live.modify(@dn,self.class.ldap_mods(attrs))
      end
    end

    def append_to_multivalue_attribute(attribute, value)
      value = Base.value_to_ldap_format(value)
      return if value.empty?

      mods = [ LDAP::Mod.new(LDAP::LDAP_MOD_ADD,attribute.to_s,value) ]
      Base.connection.ensure_connectivity do |live|
        live.modify(@dn,mods)
      end
    end

    def remove_from_multivalue_attribute(attribute, value)
      value = Base.value_to_ldap_format(value)
      return if value.empty?

      mods = [ LDAP::Mod.new(LDAP::LDAP_MOD_DELETE,attribute.to_s,value) ]
      Base.connection.ensure_connectivity do |live|
        live.modify(@dn,mods)
      end
    end

    def self.create(attrs)
      dn = attrs[:dn]
      new(attrs).save
      find(dn)
    end

    def self.ldap_mods(attributes, new_record = false)
      mods = []
      attributes.each do |name, value|
        value = value_to_ldap_format(value)
        next if new_record and value.empty?
#       if value.nil? or (value.respond_to? :empty? and value.empty?)
#         next if new_record
#         value = []
#       end
#       value = [value.to_s] unless value.is_a? Array
        mods << LDAP::Mod.new(
          (value.empty? ? LDAP::LDAP_MOD_DELETE : LDAP::LDAP_MOD_REPLACE),
          name.to_s,value)
      end
      mods
    end

    def self.value_to_ldap_format(value)
      value = [] if value.nil? or (value.respond_to? :empty? and value.empty?)
      value = [ value.to_s ] unless value.is_a? Array
      value
    end

    def destroy
      Base.connection.ensure_connectivity do |live|
        begin
          live.delete(@dn)
        rescue LDAP::ResultError => e
          if e.message == "No such object"
            raise ActiveDirectory::NotFoundError
          end
          raise
        end
      end
    end

    def save
      raise ArgumentError, 'No distinguished name (DN) specified.' if @dn.nil? or @dn.empty?

      return if @local_attributes.empty?
      if new_record?
        @local_attributes.merge!(self.class.schema_system_attributes)
        Base.connection.ensure_connectivity do |live|
          begin
            live.add(@dn, self.class.ldap_mods(@local_attributes,true))
          rescue LDAP::ResultError => e
            if e.message == 'Already exists'
              raise ActiveDirectory::AlreadyExistsError, 'Already exists'
            end
            raise
          end
        end
      else
        update_attributes(@local_attributes)
        @remote_attributes.merge!(@local_attributes)
      end
      @new_record = false
      @local_attributes = {}
    end

    def attributes=(new_attrs)
      @local_attributes = new_attrs
    end

    def initialize(attrs = {})
      @dn = attrs.delete(:dn)
      @local_attributes = attrs
      @remote_attributes = {}
      @new_record = true
    end
    
  protected
    #
    # Escape 'special characters' to their hexadecimal representation, per
    # RFC 2554.  Currently, this only escapes the '\', '(', ')' and '*'
    # characters, which have special meaning in filter strings.
    #
    def self.escape_non_ascii(unescaped_string)
      unescaped_string.gsub!(/\\/,'\\5c')
      {
        '*' => '2a',
        '(' => '28',
        ')' => '29'
      }.map { |escape,replace| unescaped_string.gsub!(escape,"\\\\#{replace}") }
      unescaped_string
    end

    def parse_ldap_attributes(entry)
      @remote_attributes = {}
      self.class.schema.each do |attr_name, attr_def|
        values = entry[attr_name.to_s]
        if values
          if attr_def[0] == Array
            @remote_attributes[attr_name] = values
          else
            @remote_attributes[attr_name] = values[0] if values[0]
          end
        end
      end
    end

    #
    # Implement our own interface to LDAP::Conn#search so that we can attempt
    # graceful reconnections when the connection becomes stale or otherwise
    # terminates.
    #
    def self.search(base_dn, scope, filter, attrs = nil) #:nodoc:
      entries = nil
      connection.ensure_connectivity do |c|
        c.search(base_dn, scope, filter, attrs) { |entry|
          entries = Array.new if entries.nil?
          entries << entry.to_hash
        }
      end
      entries
    end


  private

    def self.extract_options_from_args!(args)
      options = args.last.is_a?(Hash) ? args.pop : {}
      validate_find_options(options)
      options
    end

    def self.validate_find_options(options)
      options.assert_valid_keys [ :base, :filter, :scope ]
    end
  end

  class ActiveDirectoryError < StandardError #:nodoc:
  end
 
  class NotFoundError < StandardError #:nodoc:
  end

  class AlreadyExistsError < StandardError #:nodo:
  end

  class PasswordInvalid < StandardError #:nodoc:
  end

end
